Skip to content

Extract Tasks into the ModelContextProtocol.Extensions.Tasks extension package#1693

Merged
jeffhandley merged 11 commits into
modelcontextprotocol:mainfrom
jeffhandley:jeffhandley/tasks-ext-typed-seams
Jul 14, 2026
Merged

Extract Tasks into the ModelContextProtocol.Extensions.Tasks extension package#1693
jeffhandley merged 11 commits into
modelcontextprotocol:mainfrom
jeffhandley:jeffhandley/tasks-ext-typed-seams

Conversation

@jeffhandley

@jeffhandley jeffhandley commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Extracts the SEP-2663 Tasks feature out of ModelContextProtocol.Core into a new
bolt-on package, ModelContextProtocol.Extensions.Tasks, mirroring the existing
ModelContextProtocol.Extensions.Apps. Core is left with no compile-time knowledge of
Tasks
: the extension references the main package and layers Tasks behavior in from the
side.

Unlike Apps -- which was purely additive metadata over existing public APIs -- Tasks was
woven into Core's request-dispatch pipeline (task-augmented tools/call, redirection of
server-initiated sampling/elicitation/roots, and transparent client polling). To make
a clean side bolt-on possible, Core gains a small set of generic, typed seams. There is no
InternalsVisibleTo; the extension consumes only public Core API.

All of the new Core extensibility seams are marked [Experimental] (MCPEXP002) -- they are
preview extensibility hooks whose shape is expected to evolve (see #1704). The moved Tasks
feature APIs are non-experimental, consistent with #1642.

New public Core API surface (the seams)

ResultOrAlternate<TResult> -- a typed "result or a server-chosen alternate" envelope

namespace ModelContextProtocol.Protocol;

[Experimental("MCPEXP002")]
public class ResultOrAlternate<TResult> where TResult : Result
{
    public ResultOrAlternate(TResult result);                 // the immediate/standard result

    // The alternate arm is created through the typed factory; the untyped constructor is
    // private, so the alternate value always stays paired with its own serializer metadata.
    public static ResultOrAlternate<TResult> FromAlternate<TAlternate>(
        TAlternate alternate, JsonTypeInfo<TAlternate> alternateTypeInfo) where TAlternate : Result;

    public bool IsAlternate { get; }
    public TResult? Result { get; }
    public Result? Alternate { get; }
    public JsonTypeInfo? AlternateTypeInfo { get; }

    public static implicit operator ResultOrAlternate<TResult>(TResult result);
}

A handler that normally returns TResult can instead return an alternate Result subtype
(e.g. the Tasks extension answering tools/call with a CreateTaskResult). The alternate
carries its own JsonTypeInfo, so Core can serialize it without compile-time knowledge of
the concrete type -- keeping the path Native-AOT friendly.

Typed tools/call alternate handler + filters

namespace ModelContextProtocol.Server;

public sealed class McpServerHandlers        // reached via McpServerOptions.Handlers
{
    [Experimental("MCPEXP002")]
    public McpRequestHandler<CallToolRequestParams, ResultOrAlternate<CallToolResult>>? CallToolWithAlternateHandler { get; set; }
}

public sealed class McpRequestFilters        // reached via McpServerOptions.Filters.Request
{
    [Experimental("MCPEXP002")]
    public IList<McpRequestFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>>> CallToolWithAlternateFilters { get; }
}

The extension installs an alternate-result filter that may return a CreateTaskResult (the
"run this tool call as a task" path) while the normal typed tools/call pipeline is
preserved. The alternate handler/filters are mutually exclusive with the normal
CallToolHandler/CallToolFilters; mixing the two styles is rejected at configuration time
with an actionable error (so, today, WithTasks() cannot be combined with
AddAuthorizationFilters() -- composing them is tracked by #1704). A returned alternate that
is an InputRequiredResult is normalized by Core's MRTR backcompat resolver exactly like a
thrown InputRequiredException, so MRTR keeps working for non-MRTR clients regardless of
which form a handler uses.

Generic custom request handlers (for tasks/* methods)

namespace ModelContextProtocol.Server;

[Experimental("MCPEXP002")]
public sealed class McpServerRequestHandler
{
    public required string Method { get; init; }
    public required Func<JsonRpcRequest, CancellationToken, ValueTask<JsonNode?>> Handler { get; init; }
}

public sealed class McpServerOptions
{
    [Experimental("MCPEXP002")]
    public IList<McpServerRequestHandler>? RequestHandlers { get; set; }
}

Lets an extension own arbitrary JSON-RPC methods (tasks/get, tasks/update, tasks/cancel)
with full control over request/response serialization. This registry is method-keyed and
generic, so an extension can add more of its own RPCs with no Core change.

Scoped redirection of server-initiated outgoing requests

namespace ModelContextProtocol.Server;

public abstract partial class McpServer
{
    [Experimental("MCPEXP002")]
    public IDisposable InterceptOutgoingRequests(
        Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>> interceptor);
}

While a tool runs as a task, its sampling/elicitation/roots requests must be captured
into the task store instead of sent to the client. InterceptOutgoingRequests installs an
interceptor and returns an IDisposable whose disposal restores the previous interceptor,
so the redirection lifetime is explicit and scoped.

Client input-request resolution hook

namespace ModelContextProtocol.Client;

public abstract partial class McpClient
{
    [Experimental("MCPEXP002")]
    public abstract ValueTask<IDictionary<string, InputResponse>> ResolveInputRequestsAsync(
        IDictionary<string, InputRequest> inputRequests, CancellationToken cancellationToken);
}

A public hook that lets the extension's client-side polling loop resolve server input
requests through the registered elicitation/sampling handlers.

Protocol-version gating reuses existing public surface (McpHttpHeaders.July2026ProtocolVersion /
IsJuly2026OrLaterProtocolVersion); no new draft-detection API is added to Core.

What moves to the extension

ModelContextProtocol.Extensions.Tasks owns all Tasks-specific surface:

  • Protocol DTOs: CreateTaskResult (whose constructor sets the task result discriminator),
    GetTaskRequestParams/Result, UpdateTaskRequestParams/Result,
    CancelTaskRequestParams/Result, TaskStatusNotificationParams, McpTaskStatus,
    ResultOrCreatedTask<T>.
  • Server: IMcpTaskStore, InMemoryMcpTaskStore, McpTaskExecutionContext, McpTaskInfo,
    InputResponseReceivedEventArgs.
  • The task-augmented tools/call wrapper, the outgoing-request redirection, and the client
    polling loop -- all re-expressed on top of the Core seams above.
  • tasks/* request methods, notifications/tasks/status, the task result discriminator,
    the RelatedTask meta key, the tasks extension capability string, and the Tasks
    [JsonSerializable] registrations.

Public entry points, mirroring Apps:

// server
builder.WithTasks(store);                                 // IMcpServerBuilder
server.CreateMcpTaskScope(taskId, store);                 // IDisposable
server.SendTaskStatusNotificationAsync(...);

// client
await client.CallToolWithPollingAsync(...);               // auto-polls to completion
ResultOrCreatedTask<CallToolResult> t = await client.CallToolAsTaskAsync(...); // manual lifecycle
await client.GetTaskAsync(id); await client.UpdateTaskAsync(...); await client.CancelTaskAsync(id);

Design rationale

  • Strongly typed core path. ResultOrAlternate<TResult> and the typed
    CallToolWithAlternate* members keep the tools/call alternate path expressed in terms of
    real result types, and the envelope is a reusable, AOT-friendly primitive.
  • Scoped redirection. Outgoing-request redirection is a scoped IDisposable
    (InterceptOutgoingRequests) that restores the previous interceptor on dispose.
  • No InternalsVisibleTo. The extension depends only on public Core API, exactly like Apps.

Experimental surface

Breaking changes

This is a preview SDK and the change is intentionally breaking:

  • Tasks public API moves out of ModelContextProtocol(.Core) into
    ModelContextProtocol.Extensions.Tasks; consumers add a package reference and a using.
  • Task-store wiring and client task operations become extension methods
    (WithTasks(...), client.GetTaskAsync(...), etc.) rather than Core members.

Validation

  • dotnet build clean (0 warnings / 0 errors) across net10.0/net9.0/net8.0/netstandard2.0
    with TreatWarningsAsErrors=true.
  • Tasks and MRTR test suites pass across the target frameworks.

jeffhandley and others added 3 commits July 7, 2026 12:42
- Add ResultOrAlternate<TResult> replacing task-specific types in server pipeline
- Add McpServerRequestHandler for custom request handler registration (seam #1)
- Add McpServerOptions.RequestHandlers property with wiring in McpServerImpl
- Rename CallToolWithTaskHandler/Filters to CallToolWithAlternateHandler/Filters
- Rename SetTaskAugmented to SetWithAlternate (remove tasks/get guard)
- Rename InvokeToolAsTask to InvokeToolWithAlternate
- Rename BuildInitialTaskToolFilter to BuildInitialAlternateToolFilter
- Make McpClient.ResolveInputRequestsAsync public (seam #4)
- Update test references to use new names
- Adapt TaskHandlerConfigurationValidationTests for removed guard

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Create src/ModelContextProtocol.Extensions.Tasks/ with csproj, JSON context,
  server builder extensions, client extension methods
- Move task protocol DTOs (CreateTaskResult, GetTaskResult, UpdateTask*,
  CancelTask*, McpTaskStatus, TaskStatusNotificationParams) to extension
- Move server types (IMcpTaskStore, InMemoryMcpTaskStore, McpTaskInfo,
  InputResponseReceivedEventArgs) to extension
- Move task constants (RequestMethods.Tasks*, NotificationMethods.TaskStatus*,
  MetaKeys.RelatedTask, McpExtensions.Tasks) into TasksProtocol static class
- Delete McpTaskExecutionContext, ResultOrCreatedTask from Core
- Remove ~18 task [JsonSerializable] entries from McpJsonUtilities
- Remove McpServerOptions.TaskStore and McpClientOptions.MaxConsecutiveStuckPolls
- Remove task client methods from McpClient (CallToolRawAsync, PollTaskToCompletion,
  GetTaskAsync, UpdateTaskAsync, CancelTaskAsync, GetMetaWithTaskCapability,
  ThrowIfTasksNotSupported)
- Remove task server methods/handlers (GetTaskHandler, UpdateTaskHandler,
  CancelTaskHandler, ConfigureTasks, InvokeToolAsTask, task cancellation sources)
- Add Tasks_DiagnosticId (MCPEXP001) to Experimentals.cs
- Extension WithTasks(store) registers request handlers via seam #1,
  alternate filter via seam #2, interceptor via seam #3
- Extension client methods: CallToolAsTaskAsync, CallToolWithPollingAsync,
  GetTaskAsync, UpdateTaskAsync, CancelTaskAsync
- Manually serialize/deserialize InputResponses in UpdateTaskAsync to handle
  internal Core property visibility across assembly boundary
- Update test project and TasksExtension sample to reference new package
- Update all task test files with new using and extension API

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Restore the inline-result branch comment on the !raw.IsTask path
- Restore the CompletedTaskResult JsonElement deserialization comment
- Restore the RunReport sleep-vs-real-work comment

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tarek Mahmoud Sayed added 6 commits July 8, 2026 13:41
- Reject custom RequestHandlers that collide with built-in or duplicate methods
- Throw when an outgoing-request interceptor returns no result for sampling/roots
- Guard the Tasks background execution against unobserved store exceptions and log failures
- Add collision-guard tests for custom request handlers
Discard tasks once their advertised time-to-live elapses: expired tasks are
removed on access and a throttled opportunistic sweep reclaims expired tasks
that are never polled again. A null or non-positive TimeToLive keeps tasks for
the process lifetime, so the default behavior is unchanged.
Improve the InvalidOperationException thrown when CallToolFilters and
CallToolWithAlternateFilters are both configured, naming the common indirect
cause (combining AddAuthorizationFilters() with WithTasks()) and the current
limitation. Add filter property doc notes and tests.
The Tasks feature moved from ModelContextProtocol.Core to the new
ModelContextProtocol.Extensions.Tasks package, removing several public
APIs from Core. Regenerated the baseline suppression entries so package
validation against the 1.3.0 baseline passes.
The Tasks feature moved from ModelContextProtocol.Core to the new
ModelContextProtocol.Extensions.Tasks package, which renamed several
types and members. Updated docs/concepts/tasks/tasks.md and the task
references in docs/concepts/stateless/stateless.md so the docfx cross
references resolve and the samples match the current API:

- Types moved to the ModelContextProtocol.Extensions.Tasks namespace.
- Server enablement uses WithTasks(IMcpTaskStore) instead of the removed
  McpServerOptions.TaskStore property.
- Custom tool handlers use CallToolWithAlternateHandler returning
  ResultOrAlternate<CallToolResult>.
- Task scope uses the McpTasksServerExtensions.CreateMcpTaskScope
  extension.
- Client calls use CallToolWithPollingAsync, CallToolAsTaskAsync, and the
  Get/Update/CancelTaskAsync client extensions; the stuck-poll threshold
  is now the maxConsecutiveStuckPolls parameter.

@jeffhandley jeffhandley left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardening looks good to me, @tarekgh. We're inconsistent with throw helpers and I see that we have some places where we ifdef around them to use then on .NET but not on netfx. So you can disregard my 2 comments below as low-value suggestions that would just result in ifdef'ing, which doesn't improve the readability anyway.

I cannot approve since I started the PR, but consider me approved.

@tarekgh tarekgh force-pushed the jeffhandley/tasks-ext-typed-seams branch from 3f77adb to 3414c56 Compare July 13, 2026 19:49
tarekgh
tarekgh previously approved these changes Jul 13, 2026
Comment thread src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs
Comment thread src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs Outdated
Comment thread src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs Outdated
Comment thread src/ModelContextProtocol.Core/RequestHandlers.cs
@halter73

Copy link
Copy Markdown
Contributor

I filed #1704 to describe some follow up work that I've started on that I don't think needs to be part of this PR. I was on the fence regarding the split, but I think it's probably a good idea.

Comment thread src/ModelContextProtocol.Core/Server/McpServerHandlers.cs
- Mark ResultOrAlternate<T>, CallToolWithAlternateHandler, and
  CallToolWithAlternateFilters as [Experimental(MCPEXP002)]
- Set ResultType = "task" in the CreateTaskResult constructor and drop
  the redundant assignment in ToCreateTaskResult
- Replace the public untyped ResultOrAlternate constructor with a typed
  FromAlternate<TAlternate>(TAlternate, JsonTypeInfo<TAlternate>) factory
  and make the untyped constructor private
- Normalize a returned InputRequiredResult through the alternate path
  with a thrown InputRequiredException in the MRTR backcompat resolver
- Add MRTR tests for the returned-result form (server-side backcompat
  resolution and native MRTR round-trip)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jeffhandley jeffhandley merged commit 2ca96f5 into modelcontextprotocol:main Jul 14, 2026
11 checks passed
@jeffhandley jeffhandley added the breaking-change This issue or PR introduces a breaking change label Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change This issue or PR introduces a breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants